Проект "Анализ оттока клиентов банка "Метанпромбанк""¶
Описание проекта: банк "Метанпромбанк" нуждается в анализе оттока клиентов. Анализ покажет, какие клиенты уходят из банка, а так же поможет нам составить сегменты клиентов, которые склонны уходить из банка. Привлекать новых клиентов дорого, дешевле удержать тех, про которых мы уже что-то знаем и с которыми работаем. В отделе маркетинга банка нет автоматизированных систем рассылок, письма каждому клиенту пишутся вручную, поэтому нам важно для отдела маркетинга представить компактные однородные сегменты и дать примеры мероприятий, которые можно провести, чтобы вернуть клиентов в банк или удержать сомневающихся от оттока.
Ссылка на презентацию: PDF
Цель проекта: изучить пользователей банка и сформулировать рекомендации отделу маркетинга по возврату ушедших клиентов и удержанию тех, кто находится в “зоне риска” и склонны к уходу.
Задачи проекта:
- Провести исследовательский анализ данных, определить все значимые признаки отточности (интервалы значений характеристик, которые связаны с повышенным оттоком), сравнить портреты типичных клиентов, которые склонны и не склонны уходить из банка.
- Сформулировать и проверить статистические гипотезы.
- Объединяя признаки отточности, сформировать сегменты, отобрать из них лучшие и самые компактные и дать по ним рекомендации.
Используемые инструменты
python
pandas
numpy
scipy
matplotlib
seaborn
plotly
phik
План исследования
Загрузка и предобработка данных.
- Загрузка данных и основная информация.
- Приведение столбцов к snake_case.
- Обработка пропусков.
- Обработка дубликатов.
- Преобразование типов данных.
- Кодирование категориальных переменных.
- Проверить значения столбцов на наличие явных ошибок и аномалий.
- Проверить распределения количественных переменных, при необходимости отфильтровать выбросы.
Исследовательский анализ данных.
Составить и сравнить портреты типичного остающегося и уходящего пользователя. Рассчитать среднее и медианное значение для остающихся и уходящих клиентов, моду для категориальных признаков.
Проанализировать распределение непрерывных переменных для остающихся и уходящих клиентов и выявить интервалы с повышенным оттоком.
Рассчитать процент уходящих клиентов по категориальным признакам и выявить влияние этих признаков на отток:
- город; - возраст; - количество баллов собственности; - количество продуктов, которыми пользуется клиент; - наличие кредитной карты; - активность.Провести корреляционный анализ.
Сформулировать промежуточный вывод о признаках и интервалах значений, которые значимо влияют на отток клиентов.
Проверка статистических гипотез.
Проверить гипотезу различия дохода между теми клиентами, которые ушли и теми, которые остались.
Проверить гипотезу о более высоком балле кредитного скоринга уходящих клиентов относительно остающихся.
Проверить гипотезу о более высоком балансе уходящих клиентов относительно остающихся.
Провести сегментацию клиентов, склонных к оттоку, на основе показателей, выявленных на этапе исследовательского аналтза данных. Приоритизировать и описать полученные сегменты.
Общий вывод. Сформулировать рекомендации для отдела маркетинга. Подготовить презентацию на основе проведённого исследования.
Описание данных
Датасет bank_scrooge.csv содержит данные о клиентах банка «Метанпром».
Банк располагается в Ярославле и областных городах: Ростов Великий и Рыбинск.
Колонки:
USERID — идентификатор пользователя.
score — баллы кредитного скоринга.
city — город.
gender — пол.
age — возраст.
equity — количество баллов собственности.
balance — баланс на счёте.
products — количество продуктов, которыми пользуется клиент.
credit_card — есть ли кредитная карта.
last_activity — активный клиент.
EST_SALARY — оценочный доход клиента.
сhurn — признак оттока.
Вспомогательные функции¶
#функция для вывода гистограммы, диаграммы размаха и основных характеристик столбца
def description(df, col, bins=100):
try:
plt.figure(figsize=(15,10))
#гистограмма распределения
ax1 = plt.subplot(2,1, 1)
ax1 = sns.histplot(df[col], bins=bins)
ax1.set_title('Гистограмма значений столбца')
ax1.set_xlabel('Значения')
ax1.set_ylabel('Частота')
#диаграмма размаха
ax2 = plt.subplot(2,1, 2, sharex=ax1)
ax2 = sns.violinplot(x=col, data=df)
ax2.set_title('Распределение значений и диаграмма размаха')
ax2.set_xlabel('Значения')
ax2.set_ylabel('Частота')
plt.show()
#основные характеристики
display('Основные характеристики столбца', df[col].describe())
except:
display('Ошибка работы функции')
#функция для вычисления первого и второго квартиля и межквартильного размаха
def q_iqr(series):
try:
#первый квартиль
q1 = np.percentile(
series,
25)
#третий квартиль
q3 = np.percentile(
series,
75)
#межквартильный размах
iqr = q3 - q1
#нижняя граница выбросов
lower_fringe = q1 - iqr * 1.5
#верхняя граница выбросов
upper_fringe = q3 + iqr * 1.5
return [q1, q3, iqr, lower_fringe, upper_fringe]
except:
return 'Ошибка работы функции'
#функция для сегментации по непрерывному количественному признаку
def segmentation(dict, orig_df, col, col_name, total_rate):
'''
Функция принимает словарь с границами сегментов, исходный датафрейм,
название столбца исходного датафрейма, название признака, для которого
указаны границы сегментов и процент оттока для всех клиентов.
Возвращает датафрейм с границами сегментов, их размерами
и отношением оттока сегментов к общему оттоку всех клиентов
'''
try:
#создание датафрейма с границами сегментов
segments_df = pd.DataFrame(
dict
).T
#создание набора строк с характеристиками сегмента в виде списка
segments_df.columns = ['min','max']
list=[]
for seg in segments_df.index:
min = segments_df.loc[seg]['min']
max = segments_df.loc[seg]['max']
list.append([
orig_df[(orig_df[col] >= min) & \
(orig_df[col] <= max)]['churn'].mean() * 100,
(orig_df[(orig_df[col] >= min) & \
(orig_df[col] <= max)]['churn'].mean() * 100 / total_rate - 1) * 100,
orig_df[(df[col] >= min) & \
(orig_df[col] <= max)]['churn'].count(),
])
#добавление списка строк к датафрейму с границами сегментов
segments_df[[
'churn_rate',
'churn_to_total_churn_diff_%',
'segment_size']] = list
#приведение данных к удобному виду
segments_df[
'churn_rate'
] = segments_df[
'churn_rate'
].map('{:.2f}'.format).astype('float')
segments_df[
'churn_to_total_churn_diff_%'
] = segments_df[
'churn_to_total_churn_diff_%'
].map('{:.2f}'.format).astype('float')
segments_df['segment_size'] = segments_df['segment_size'].astype('int')
segments_df[['min','max']] = segments_df[['min','max']].astype('str')
#добавление столбца с текстовым описанием границ сегмента
index = []
for ind in segments_df.index:
index.append(
col + '_' + segments_df.loc[ind,'min'] + '-' + segments_df.loc[ind,'max']
)
segments_df['segment']=index
#удаление стобцов со значениями границ сегмента
segments_df = (segments_df
.drop(columns=['min','max'])
.sort_values(
by='churn_rate',
ascending=False
)
)
#построение столбчатой диаграммы
plt.figure(figsize=(15,segments_df.shape[0]))
ax = sns.barplot(
segments_df,
x='churn_rate',
y='segment',
orient='y',
order=segments_df.sort_values(
by='churn_rate',
ascending=False
)['segment']
)
plt.axvline(
total_rate,
linestyle='--',
color='red',
label='Общий процент оттока'
)
plt.legend(loc='upper center')
ax.bar_label(ax.containers[0], fontsize=12)
ax.set_title(f'Процент оттока по: {col}')
ax.set_xlabel('Процент оттока')
ax.set_ylabel(f'Признак: {col}');
#сохранение изображения
plt_name = pics_path + 'Процент оттока по признаку ' + col_name + '.png'
plt.savefig(
plt_name,
bbox_inches= 'tight'
)
plt.show();
#функция возвращает датафрейм с полученной таблицей
return segments_df
except:
display('Ошибка работы функции')
#импорт необходимых библиотек и установка инструментов
!pip install missingno
import missingno as msno
!pip install openpyxl
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
from scipy import stats as st
import plotly.express as px
!pip install phik
import phik
#настройка ширины отображаемых колонок
pd.set_option('max_colwidth', 120)
#путь к файлу с данными
data_path = ''
#путь для сохранения изображений для презентации
pics_path = 'pics_banks_churn_analysis\\'
#выбор цветового стиля и палитры по умолчанию
plt.style.use('ggplot')
sns.set_palette('tab20')
#отключение текстовых предупреждений
import warnings
warnings.filterwarnings("ignore")
Requirement already satisfied: missingno in c:\programdata\anaconda3\lib\site-packages (0.5.2) Requirement already satisfied: numpy in c:\programdata\anaconda3\lib\site-packages (from missingno) (1.26.4) Requirement already satisfied: matplotlib in c:\programdata\anaconda3\lib\site-packages (from missingno) (3.8.3) Requirement already satisfied: scipy in c:\programdata\anaconda3\lib\site-packages (from missingno) (1.13.0) Requirement already satisfied: seaborn in c:\programdata\anaconda3\lib\site-packages (from missingno) (0.13.2) Requirement already satisfied: contourpy>=1.0.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (1.2.0) Requirement already satisfied: cycler>=0.10 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (0.12.1) Requirement already satisfied: fonttools>=4.22.0 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (4.50.0) Requirement already satisfied: kiwisolver>=1.3.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (1.4.5) Requirement already satisfied: packaging>=20.0 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (23.1) Requirement already satisfied: pillow>=8 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (10.2.0) Requirement already satisfied: pyparsing>=2.3.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (3.1.2) Requirement already satisfied: python-dateutil>=2.7 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (2.8.2) Requirement already satisfied: pandas>=1.2 in c:\programdata\anaconda3\lib\site-packages (from seaborn->missingno) (2.1.4) Requirement already satisfied: pytz>=2020.1 in c:\programdata\anaconda3\lib\site-packages (from pandas>=1.2->seaborn->missingno) (2023.3.post1) Requirement already satisfied: tzdata>=2022.1 in c:\programdata\anaconda3\lib\site-packages (from pandas>=1.2->seaborn->missingno) (2023.3) Requirement already satisfied: six>=1.5 in c:\programdata\anaconda3\lib\site-packages (from python-dateutil>=2.7->matplotlib->missingno) (1.16.0) Requirement already satisfied: openpyxl in c:\programdata\anaconda3\lib\site-packages (3.1.2) Requirement already satisfied: et-xmlfile in c:\programdata\anaconda3\lib\site-packages (from openpyxl) (1.1.0) Requirement already satisfied: phik in c:\programdata\anaconda3\lib\site-packages (0.12.4) Requirement already satisfied: numpy>=1.18.0 in c:\programdata\anaconda3\lib\site-packages (from phik) (1.26.4) Requirement already satisfied: scipy>=1.5.2 in c:\programdata\anaconda3\lib\site-packages (from phik) (1.13.0) Requirement already satisfied: pandas>=0.25.1 in c:\programdata\anaconda3\lib\site-packages (from phik) (2.1.4) Requirement already satisfied: matplotlib>=2.2.3 in c:\programdata\anaconda3\lib\site-packages (from phik) (3.8.3) Requirement already satisfied: joblib>=0.14.1 in c:\programdata\anaconda3\lib\site-packages (from phik) (1.4.2) Requirement already satisfied: contourpy>=1.0.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (1.2.0) Requirement already satisfied: cycler>=0.10 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (0.12.1) Requirement already satisfied: fonttools>=4.22.0 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (4.50.0) Requirement already satisfied: kiwisolver>=1.3.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (1.4.5) Requirement already satisfied: packaging>=20.0 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (23.1) Requirement already satisfied: pillow>=8 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (10.2.0) Requirement already satisfied: pyparsing>=2.3.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (3.1.2) Requirement already satisfied: python-dateutil>=2.7 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (2.8.2) Requirement already satisfied: pytz>=2020.1 in c:\programdata\anaconda3\lib\site-packages (from pandas>=0.25.1->phik) (2023.3.post1) Requirement already satisfied: tzdata>=2022.1 in c:\programdata\anaconda3\lib\site-packages (from pandas>=0.25.1->phik) (2023.3) Requirement already satisfied: six>=1.5 in c:\programdata\anaconda3\lib\site-packages (from python-dateutil>=2.7->matplotlib>=2.2.3->phik) (1.16.0)
#загрузка данных
df = pd.read_csv(data_path + 'bank_scrooge.csv')
#копия датафрейма до предобработки
df_copy = df.copy()
#основная информация о датафрейме
df.info()
#вывод первых строк датафрейма
df.head(20)
<class 'pandas.core.frame.DataFrame'> RangeIndex: 10000 entries, 0 to 9999 Data columns (total 12 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 USERID 10000 non-null int64 1 score 10000 non-null float64 2 city 10000 non-null object 3 gender 10000 non-null object 4 age 9974 non-null float64 5 equity 10000 non-null int64 6 balance 7705 non-null float64 7 products 10000 non-null int64 8 credit_card 10000 non-null int64 9 last_activity 10000 non-null int64 10 EST_SALARY 10000 non-null float64 11 churn 10000 non-null int64 dtypes: float64(4), int64(6), object(2) memory usage: 937.6+ KB
| USERID | score | city | gender | age | equity | balance | products | credit_card | last_activity | EST_SALARY | churn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 183012 | 850.0 | Рыбинск | Ж | 25.0 | 1 | 59214.82 | 2 | 0 | 1 | 75719.14 | 1 |
| 1 | 146556 | 861.0 | Рыбинск | Ж | 37.0 | 5 | 850594.33 | 3 | 1 | 0 | 86621.77 | 0 |
| 2 | 120722 | 892.0 | Рыбинск | Ж | 30.0 | 0 | NaN | 1 | 1 | 1 | 107683.34 | 0 |
| 3 | 225363 | 866.0 | Ярославль | Ж | 51.0 | 5 | 1524746.26 | 2 | 0 | 1 | 174423.53 | 1 |
| 4 | 157978 | 730.0 | Ярославль | М | 34.0 | 5 | 174.00 | 1 | 1 | 0 | 67353.16 | 1 |
| 5 | 202305 | 856.0 | Рыбинск | М | 56.0 | 4 | 863687.24 | 3 | 1 | 0 | 156619.80 | 0 |
| 6 | 177259 | 807.0 | Ярославль | Ж | 39.0 | 3 | 405042.44 | 3 | 0 | 1 | 103838.32 | 0 |
| 7 | 218868 | 825.0 | Ярославль | Ж | 38.0 | 4 | 458145.40 | 2 | 1 | 1 | 68085.48 | 0 |
| 8 | 211686 | 923.0 | Ярославль | М | 54.0 | 5 | 1206337.87 | 2 | 1 | 0 | 155371.79 | 0 |
| 9 | 133130 | 906.0 | Ярославль | Ж | 67.0 | 0 | NaN | 1 | 0 | 1 | 238055.53 | 0 |
| 10 | 148929 | 927.0 | Ростов | М | 52.0 | 0 | NaN | 1 | 1 | 1 | 196820.07 | 0 |
| 11 | 172184 | 921.0 | Ростов | М | 41.0 | 0 | NaN | 1 | 1 | 1 | 217469.48 | 0 |
| 12 | 132970 | 845.0 | Ярославль | Ж | 31.0 | 3 | 591928.52 | 2 | 1 | 1 | 112697.16 | 1 |
| 13 | 166357 | 837.0 | Рыбинск | Ж | 28.0 | 4 | 302249.22 | 2 | 0 | 1 | 30950.94 | 0 |
| 14 | 172138 | 815.0 | Ярославль | М | 35.0 | 5 | 547499.87 | 2 | 1 | 1 | 105883.26 | 0 |
| 15 | 120260 | 731.0 | Рыбинск | М | 42.0 | 3 | 1480548.47 | 3 | 1 | 0 | 160974.43 | 0 |
| 16 | 123335 | 829.0 | Ярославль | М | 45.0 | 5 | 507842.84 | 1 | 1 | 1 | 169330.64 | 0 |
| 17 | 214277 | 793.0 | Ярославль | М | 53.0 | 3 | 627034.50 | 2 | 1 | 0 | 183430.19 | 0 |
| 18 | 136651 | 876.0 | Ярославль | М | 40.0 | 5 | 2307947.36 | 2 | 0 | 0 | 287010.61 | 0 |
| 19 | 127034 | 922.0 | Рыбинск | Ж | 53.0 | 0 | NaN | 1 | 0 | 0 | 147094.82 | 0 |
В датасете 10000 строк, данные отображаются корректно.
Названия столбцов нужно привести к snake_case.Названия столбцов нужно привести к snake_case.
Для оптимизации обработки переменных необходимо преобразовать тип данных в int в столбцах, которые должны содержать только целочисленные значения:
- score
- age
В датасете есть пропуски в столбцах age и balance
Переименование столбцов.¶
df.columns = df.columns.str.lower()
df.columns
Index(['userid', 'score', 'city', 'gender', 'age', 'equity', 'balance',
'products', 'credit_card', 'last_activity', 'est_salary', 'churn'],
dtype='object')
Обработка пропусков.¶
#диаграмма пропущенных значений
ax = msno.bar(df, figsize=(15,7))
ax.set_title(
'Соотношение корректных и пропущенных значений в столбцах\n',
fontsize=16
)
ax.set_xlabel('Столбцы', fontsize=16)
ax.set_ylabel('Значения', fontsize=16)
plt.show();
#вывод количества пропущенных значений для каждого столбца
nans = df.isna().sum().to_frame()
nans.insert(
loc=1,
column='rate_%',
value= round(df.isna().sum() *100 / len(df.index), 2)
)
nans
| 0 | rate_% | |
|---|---|---|
| userid | 0 | 0.00 |
| score | 0 | 0.00 |
| city | 0 | 0.00 |
| gender | 0 | 0.00 |
| age | 26 | 0.26 |
| equity | 0 | 0.00 |
| balance | 2295 | 22.95 |
| products | 0 | 0.00 |
| credit_card | 0 | 0.00 |
| last_activity | 0 | 0.00 |
| est_salary | 0 | 0.00 |
| churn | 0 | 0.00 |
Столбец age.¶
Пропусков в столбце 26 (0.26%). Заполнить данные по этому столбцу не представляется возможным - для этого нужно знать действительный возраст клиентов, заполнение синтетическими данными здесь не подойдёт. Для анализа и классификации пропусков их количество слишком мало.
Учитывая, что пропусков очень мало, целесообразно их просто удалить.
df = df.dropna(subset=['age'])
Столбец balance.¶
Пропусков в столбце 2293 (22.95%).
Изучим зависимость пропусков в столбце от имеющихся данных.
Кодирование пропусков и построение сводной таблицы зависимости.
#кодирование пропусков в столбце
df['isnan'] = df['balance'].isna().map(lambda x: 1 if x else 0)
#построение сводной таблицы мер центральной тенденции
pivot_nan = df.groupby('isnan')[[
'score',
'city',
'gender',
'equity',
'age',
'products',
'credit_card',
'last_activity',
'est_salary',
'churn'
]].agg(
{
'score':['mean','median'],
'city':pd.Series.mode,
'gender':pd.Series.mode,
'equity':pd.Series.mode,
'age':'median',
'products':'median',
'credit_card':pd.Series.mode,
'last_activity':pd.Series.mode,
'est_salary':['mean', 'median'],
'churn':pd.Series.mode
}
)
#транспонирование таблицы
pivot_nan = pivot_nan.T
#переименование столбцов
pivot_nan.columns = ['value','nan']
#таблица для количественных переменных
pivot_nan_quant = pivot_nan.loc[['score','est_salary','age'],:]
pivot_nan_quant['difference_%'] = (
(pivot_nan_quant['nan'] - pivot_nan_quant['value']) / pivot_nan_quant['value']* 100
)
display(pivot_nan)
pivot_nan_quant
| value | nan | ||
|---|---|---|---|
| score | mean | 843.74412 | 865.357174 |
| median | 840.0 | 903.0 | |
| city | mode | Ярославль | Ярославль |
| gender | mode | Ж | М |
| equity | mode | 5 | 0 |
| age | median | 40.0 | 40.0 |
| products | median | 2.0 | 1.0 |
| credit_card | mode | 1 | 1 |
| last_activity | mode | 1 | 1 |
| est_salary | mean | 124520.394264 | 226345.417464 |
| median | 106250.18 | 174347.87 | |
| churn | mode | 0 | 0 |
| value | nan | difference_% | ||
|---|---|---|---|---|
| score | mean | 843.74412 | 865.357174 | 2.561565 |
| median | 840.0 | 903.0 | 7.5 | |
| est_salary | mean | 124520.394264 | 226345.417464 | 81.773772 |
| median | 106250.18 | 174347.87 | 64.091835 | |
| age | median | 40.0 | 40.0 | 0.0 |
Значения мер различаются для пропусков и заполненных значений в столбцах gender, equity, products, score и est_salary.
Различие мер в столбце score небольшое - всего 7,5% для медианы и 2,56% для среднего, зависимости пропусков от значений столбца здесь нет.
В столбце est_salary медианное и среднее значения отличаются довольно сильно - на 81,77% b 64.09%.
Проведём тест независимости наличия пропусков и пола (gender) с помощью критерия V Крамера.
#таблица сопряжённости
tab = pd.crosstab(index=df['gender'], columns=df['isnan'])
#значение критерия
result = st.chi2_contingency(tab)
#значение V Крамера
V = np.sqrt ((result[0]/len(df)) / (min(tab.shape) - 1))
V
0.0626051485739025
Критерий показывает наличие очень слабой взаимосвязи, ей можно пренебречь.
Далее, подсчитаем коэффициент корреляции Пирсона для тех из содержащих заметную разницу столбцов, которые содержат непрерывные, дискретные и упорядоченные категориальные переменные.
#построение тепловой карты
plt.figure(figsize=(7,7))
ax = sns.heatmap(
(df[[
'isnan',
'equity',
'products',
'score',
'est_salary'
]].corr()['isnan']
.drop('isnan')
.sort_values(ascending=False)
.to_frame()),
annot=True,
cmap='coolwarm',
fmt='.2f',
center=0
);
ax.set_ylabel('Коэффициент корелляции со столбцами')
ax.set_xlabel('Индикатор пропусков в столбце "balance"')
ax.set_title('Коэффициент корреляции Пирсона для пропусков в столбце "balance" и числовых переменных')
plt.show();
По шкале Чеддока корреляция достаточно сильная отрицательная корреляция только со столбцом equity, также корреляция со столбцом products на границе слабой и средней; пропуски нельзя отнести к категории MCAR.
Изучим процент пропусков в разбивке по значениям этих столбцов.
Также нужно учитывать, что значения столбца equity - не дискретная переменная, а упорядоченная категориальная.
#процент пропусков для значений столбца "equity"
pivot = df.pivot_table(
index='equity',
values='userid',
columns='isnan',
aggfunc='count'
).fillna(0)
pivot['total'] = pivot.sum(axis=1)
pivot['rate_%'] = round(pivot[1] * 100 / pivot['total'], 2)
pivot['nan_%'] = round(pivot[1] / pivot[1].sum(axis=0), 2)
pivot
| isnan | 0 | 1 | total | rate_% | nan_% |
|---|---|---|---|---|---|
| equity | |||||
| 0 | 412.0 | 2164.0 | 2576.0 | 84.01 | 0.95 |
| 1 | 659.0 | 114.0 | 773.0 | 14.75 | 0.05 |
| 2 | 1051.0 | 0.0 | 1051.0 | 0.00 | 0.00 |
| 3 | 1540.0 | 1.0 | 1541.0 | 0.06 | 0.00 |
| 4 | 1847.0 | 0.0 | 1847.0 | 0.00 | 0.00 |
| 5 | 1915.0 | 0.0 | 1915.0 | 0.00 | 0.00 |
| 6 | 161.0 | 0.0 | 161.0 | 0.00 | 0.00 |
| 7 | 80.0 | 0.0 | 80.0 | 0.00 | 0.00 |
| 8 | 17.0 | 0.0 | 17.0 | 0.00 | 0.00 |
| 9 | 13.0 | 0.0 | 13.0 | 0.00 | 0.00 |
##процент пропусков для значений столбца "products"
pivot = df.pivot_table(
index='products',
values='userid',
columns='isnan',
aggfunc='count'
).fillna(0)
pivot['total'] = pivot.sum(axis=1)
pivot['rate_%'] = round(pivot[1] * 100 / pivot['total'], 2)
pivot['nan_%'] = round(pivot[1] / pivot[1].sum(axis=0), 2)
pivot
| isnan | 0 | 1 | total | rate_% | nan_% |
|---|---|---|---|---|---|
| products | |||||
| 0 | 0.0 | 1.0 | 1.0 | 100.00 | 0.00 |
| 1 | 1389.0 | 1934.0 | 3323.0 | 58.20 | 0.85 |
| 2 | 4804.0 | 315.0 | 5119.0 | 6.15 | 0.14 |
| 3 | 1009.0 | 29.0 | 1038.0 | 2.79 | 0.01 |
| 4 | 474.0 | 0.0 | 474.0 | 0.00 | 0.00 |
| 5 | 19.0 | 0.0 | 19.0 | 0.00 | 0.00 |
Дополнительно подсчитаем процент пропусков для Наблюдений с equity = 0 и products = 1.
round(
df[
(df['equity'] == 0) & (df['products'] == 1)
]['balance'].isna().sum() / df['balance'].isna().sum() * 100,
2
)
80.56
Налицо две явные зависимости между количеством пропусков и значениями столбцов:
- чем ниже балл собственности, тем выше процент пропусков; почти все пропуски сосредоточены в категориях "0" и "1", в категории "0" расположены 95% всех пропусков;
- чем меньше у клиента продуктов, тем выше процент пропусков; почти все пропуски сосредоточены в категориях "1" и "2", в категории "1" расположены 85% всех пропусков;
Можно сделать вывод, что наличие пропусков в столбце balance зависит от имеющихся данных, что позволяет отнести их к категории MAR.
80,56% пропусков относятся к группе клиентов с баллом собственности 0 и количеством продуктов 1. Возможно, речь идёт о группе малоимущих клиентов либо клиентов, которые мало пользуются продуктами банка - причину пропусков можно попробовать выяснить у заказчика, в любом случае на это стоит обратить его внимание.
Пропусков в этой группе слишком много, чтобы их удалять или заполнять синтетическими данными, но можно выделить эту группу и при анализе по столбцу balance рассматривать её отдельно от других, имея в виду недостаток наблюдений по этому набору признаков.
Пропуски для групп клиентов с количеством продуктов более 1 и баллом собственности более 1 можно удалить ввиду их малого количества.
#удаление
df = df.drop(df[
~((df['equity'] == 0) & (df['products'] == 1)) & df['balance'].isna()
].index)
#переменная с индексами группы клиентов, содержащей большой процент пропусков
nan_clients = df.query('(equity == 0) & (products == 1)').index
#удаление вспомогательного столбца
df = df.drop(columns='isnan')
Количество пропусков после их обработки.
#диаграмма пропущенных значений
ax = msno.bar(df, figsize=(15,7))
ax.set_title(
'Соотношение корректных и пропущенных значений в столбцах\n',
fontsize=16
)
ax.set_xlabel('Столбцы', fontsize=16)
ax.set_ylabel('Значения', fontsize=16)
plt.show();
Обработка дубликатов¶
Явные дубликаты.
df.duplicated().sum()
0
Неявные дубликаты могут присутствовать в столбце userid.
df['userid'].duplicated().sum()
44
44 дубликата. Изучим эти строки более подробно.
id_dups = df[df['userid'].duplicated(keep=False)].sort_values(by='userid')
pd.set_option('display.max_rows', 88)
id_dups
| userid | score | city | gender | age | equity | balance | products | credit_card | last_activity | est_salary | churn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 7694 | 116540 | 887.0 | Ярославль | Ж | 38.0 | 0 | NaN | 1 | 0 | 1 | 119247.61 | 0 |
| 1893 | 116540 | 883.0 | Рыбинск | Ж | 55.0 | 1 | 362756.49 | 3 | 0 | 1 | 175920.48 | 1 |
| 7542 | 117943 | 880.0 | Ярославль | Ж | 40.0 | 0 | NaN | 1 | 1 | 0 | 137718.93 | 0 |
| 4866 | 117943 | 855.0 | Рыбинск | Ж | 32.0 | 6 | 1036832.93 | 4 | 1 | 1 | 107792.71 | 1 |
| 5863 | 120258 | 908.0 | Рыбинск | Ж | 38.0 | 4 | 2213581.63 | 2 | 0 | 1 | 160327.77 | 1 |
| 5896 | 120258 | 905.0 | Ярославль | М | 30.0 | 0 | NaN | 1 | 1 | 1 | 146427.96 | 0 |
| 9775 | 124450 | 758.0 | Рыбинск | Ж | 36.0 | 0 | 73574.07 | 2 | 0 | 1 | 79963.59 | 0 |
| 8164 | 124450 | 859.0 | Ярославль | Ж | 38.0 | 6 | 516811.20 | 1 | 1 | 1 | 95144.63 | 1 |
| 5420 | 126368 | 832.0 | Рыбинск | Ж | 33.0 | 0 | NaN | 1 | 0 | 0 | 118465.34 | 0 |
| 4850 | 126368 | 909.0 | Ярославль | Ж | 30.0 | 4 | 1322623.29 | 2 | 0 | 1 | 140675.66 | 1 |
| 9504 | 129785 | 949.0 | Ярославль | М | 43.0 | 0 | NaN | 1 | 1 | 1 | 166845.37 | 0 |
| 3988 | 129785 | 904.0 | Рыбинск | М | 33.0 | 5 | 2427737.60 | 3 | 0 | 1 | 188856.75 | 1 |
| 2138 | 131419 | 739.0 | Рыбинск | Ж | 37.0 | 3 | 1307941.65 | 3 | 0 | 0 | 103867.47 | 0 |
| 904 | 131419 | 934.0 | Ярославль | М | 35.0 | 5 | 2348335.95 | 2 | 0 | 0 | 217105.13 | 1 |
| 1608 | 140377 | 802.0 | Ярославль | М | 26.0 | 3 | 539863.29 | 4 | 1 | 1 | 112167.83 | 1 |
| 7487 | 140377 | 930.0 | Рыбинск | Ж | 34.0 | 5 | 1123967.02 | 1 | 1 | 1 | 148336.26 | 0 |
| 2276 | 141265 | 858.0 | Ярославль | М | 37.0 | 4 | 471599.16 | 2 | 1 | 1 | 148806.88 | 1 |
| 5569 | 141265 | 811.0 | Рыбинск | Ж | 33.0 | 3 | 1044316.88 | 2 | 1 | 0 | 46055.32 | 0 |
| 5369 | 143592 | 885.0 | Рыбинск | Ж | 56.0 | 4 | 1201087.46 | 2 | 1 | 1 | 88223.89 | 1 |
| 5557 | 143592 | 764.0 | Ярославль | Ж | 68.0 | 3 | 508919.15 | 2 | 0 | 1 | 71211.66 | 0 |
| 8272 | 149365 | 793.0 | Рыбинск | Ж | 61.0 | 0 | 476496.99 | 2 | 1 | 1 | 46147.06 | 0 |
| 946 | 149365 | 877.0 | Ярославль | М | 41.0 | 5 | 819182.01 | 2 | 0 | 0 | 212316.47 | 1 |
| 9267 | 150667 | 931.0 | Ярославль | М | 41.0 | 0 | NaN | 1 | 0 | 0 | 261904.44 | 0 |
| 2439 | 150667 | 813.0 | Рыбинск | Ж | 44.0 | 0 | 62504.47 | 4 | 1 | 0 | 152393.79 | 1 |
| 690 | 152479 | 768.0 | Рыбинск | Ж | 42.0 | 0 | 126787.79 | 4 | 1 | 1 | 143233.23 | 1 |
| 5705 | 152479 | 723.0 | Ярославль | Ж | 22.0 | 0 | NaN | 1 | 1 | 0 | 83955.65 | 0 |
| 3274 | 155765 | 863.0 | Ярославль | М | 30.0 | 5 | 1036114.50 | 5 | 1 | 1 | 150744.50 | 1 |
| 5197 | 155765 | 923.0 | Рыбинск | М | 30.0 | 0 | NaN | 1 | 1 | 1 | 120296.60 | 0 |
| 1080 | 155872 | 896.0 | Ярославль | М | 36.0 | 3 | 1113656.92 | 3 | 0 | 1 | 291176.85 | 1 |
| 9363 | 155872 | 714.0 | Рыбинск | Ж | 29.0 | 0 | NaN | 1 | 1 | 0 | 118052.84 | 0 |
| 9508 | 160075 | 824.0 | Рыбинск | Ж | 57.0 | 3 | 355047.33 | 2 | 0 | 0 | 64351.81 | 0 |
| 3936 | 160075 | 899.0 | Ярославль | М | 34.0 | 4 | 1647814.67 | 4 | 1 | 0 | 151888.42 | 1 |
| 5629 | 162053 | 921.0 | Рыбинск | М | 33.0 | 3 | 857011.13 | 2 | 1 | 1 | 113512.57 | 1 |
| 8733 | 162053 | 786.0 | Ярославль | М | 29.0 | 4 | 205824.97 | 3 | 1 | 0 | 121371.73 | 0 |
| 3564 | 163207 | 853.0 | Рыбинск | М | 42.0 | 4 | 543839.62 | 1 | 1 | 1 | 105281.97 | 1 |
| 6786 | 163207 | 838.0 | Ярославль | Ж | 42.0 | 4 | 652776.60 | 2 | 1 | 1 | 97545.36 | 0 |
| 7271 | 164676 | 790.0 | Рыбинск | Ж | 77.0 | 1 | 111202.01 | 1 | 1 | 0 | 32835.45 | 0 |
| 58 | 164676 | 860.0 | Ярославль | М | 37.0 | 5 | 648594.03 | 2 | 1 | 1 | 213586.86 | 1 |
| 9785 | 168998 | 895.0 | Рыбинск | М | 44.0 | 0 | 282653.53 | 3 | 0 | 0 | 255296.47 | 0 |
| 5020 | 168998 | 886.0 | Ярославль | Ж | 35.0 | 4 | 394780.61 | 2 | 1 | 1 | 510577.10 | 1 |
| 3039 | 170312 | 813.0 | Рыбинск | Ж | 47.0 | 3 | 370027.67 | 2 | 0 | 0 | 129835.56 | 0 |
| 790 | 170312 | 901.0 | Ярославль | М | 33.0 | 4 | 3276783.93 | 3 | 0 | 1 | 358700.96 | 1 |
| 4587 | 171751 | 874.0 | Рыбинск | Ж | 26.0 | 5 | 292054.42 | 2 | 0 | 1 | 20528.56 | 1 |
| 9653 | 171751 | 862.0 | Ярославль | М | 42.0 | 4 | 481992.19 | 2 | 1 | 0 | 71816.86 | 0 |
| 2941 | 172142 | 896.0 | Ярославль | Ж | 30.0 | 4 | 1009073.14 | 4 | 1 | 0 | 70866.29 | 1 |
| 6756 | 172142 | 751.0 | Рыбинск | Ж | 69.0 | 3 | 396568.03 | 2 | 0 | 1 | 45608.45 | 0 |
| 9970 | 175730 | 816.0 | Рыбинск | М | 36.0 | 4 | 477892.07 | 3 | 1 | 0 | 81100.60 | 0 |
| 7753 | 175730 | 846.0 | Ярославль | Ж | 32.0 | 7 | 216764.74 | 4 | 1 | 1 | 77100.85 | 1 |
| 8705 | 181526 | 895.0 | Рыбинск | М | 29.0 | 0 | NaN | 1 | 1 | 1 | 124786.53 | 0 |
| 2989 | 181526 | 918.0 | Ярославль | Ж | 37.0 | 5 | 1366382.35 | 4 | 1 | 1 | 97932.95 | 1 |
| 9146 | 183510 | 800.0 | Рыбинск | Ж | 42.0 | 7 | 473293.81 | 2 | 1 | 0 | 54049.88 | 0 |
| 8584 | 183510 | 922.0 | Ярославль | М | 26.0 | 5 | 448542.42 | 3 | 0 | 1 | 66063.13 | 1 |
| 150 | 185748 | 850.0 | Рыбинск | М | 36.0 | 5 | 1203376.67 | 2 | 0 | 1 | 89401.82 | 1 |
| 3106 | 185748 | 912.0 | Ярославль | Ж | 47.0 | 5 | 1598218.98 | 2 | 0 | 0 | 180256.98 | 0 |
| 5454 | 188957 | 853.0 | Ярославль | М | 35.0 | 1 | 374959.62 | 2 | 1 | 1 | 197600.97 | 1 |
| 6005 | 188957 | 882.0 | Рыбинск | Ж | 64.0 | 3 | 893392.91 | 3 | 1 | 0 | 69622.58 | 0 |
| 1583 | 190253 | 726.0 | Ярославль | М | 49.0 | 0 | NaN | 1 | 1 | 1 | 177700.78 | 0 |
| 231 | 190253 | 823.0 | Рыбинск | М | 37.0 | 4 | 373348.39 | 2 | 0 | 1 | 131947.92 | 1 |
| 3859 | 191520 | 773.0 | Рыбинск | М | 36.0 | 3 | 1219918.08 | 4 | 1 | 1 | 119933.13 | 1 |
| 7931 | 191520 | 767.0 | Ярославль | М | 45.0 | 0 | NaN | 1 | 1 | 1 | 880005.05 | 0 |
| 9659 | 195884 | 767.0 | Рыбинск | Ж | 45.0 | 1 | 335652.56 | 3 | 1 | 1 | 100197.67 | 0 |
| 2226 | 195884 | 906.0 | Ярославль | М | 49.0 | 6 | 1354552.83 | 2 | 0 | 0 | 270678.42 | 1 |
| 9035 | 200863 | 933.0 | Рыбинск | М | 29.0 | 7 | 766924.56 | 2 | 0 | 1 | 89323.33 | 1 |
| 9175 | 200863 | 832.0 | Ярославль | М | 42.0 | 5 | 620064.73 | 2 | 0 | 0 | 188054.46 | 0 |
| 1513 | 208081 | 873.0 | Ярославль | М | 31.0 | 2 | 232814.75 | 2 | 1 | 1 | 122505.86 | 1 |
| 2998 | 208081 | 821.0 | Рыбинск | Ж | 62.0 | 3 | 493608.26 | 2 | 1 | 1 | 33491.45 | 0 |
| 5840 | 208738 | 839.0 | Ярославль | М | 60.0 | 5 | 1102438.37 | 2 | 0 | 1 | 116500.81 | 0 |
| 1580 | 208738 | 897.0 | Рыбинск | Ж | 45.0 | 5 | 859018.19 | 2 | 1 | 1 | 145510.08 | 1 |
| 5570 | 208815 | 773.0 | Ярославль | М | 39.0 | 4 | 288110.09 | 2 | 1 | 0 | 56971.91 | 0 |
| 3350 | 208815 | 877.0 | Рыбинск | М | 25.0 | 4 | 239421.18 | 1 | 1 | 1 | 48638.00 | 1 |
| 7453 | 210627 | 864.0 | Ярославль | Ж | 38.0 | 4 | 1527225.32 | 4 | 1 | 1 | 162999.17 | 1 |
| 8521 | 210627 | 804.0 | Рыбинск | М | 30.0 | 2 | 347818.88 | 3 | 1 | 0 | 129015.25 | 0 |
| 4986 | 210792 | 884.0 | Рыбинск | М | 48.0 | 4 | 2228037.20 | 2 | 0 | 1 | 207978.09 | 1 |
| 7814 | 210792 | 839.0 | Ярославль | М | 42.0 | 2 | 662646.97 | 1 | 1 | 0 | 138403.60 | 0 |
| 9880 | 217412 | 886.0 | Ярославль | Ж | 35.0 | 3 | 1026172.14 | 2 | 1 | 0 | 53099.16 | 0 |
| 7055 | 217412 | 879.0 | Рыбинск | М | 42.0 | 5 | 568140.15 | 3 | 0 | 0 | 231658.67 | 1 |
| 4611 | 217619 | 903.0 | Рыбинск | Ж | 59.0 | 5 | 844055.83 | 2 | 0 | 1 | 133999.76 | 1 |
| 5807 | 217619 | 766.0 | Ярославль | М | 52.0 | 2 | 218199.47 | 1 | 1 | 0 | 120356.44 | 0 |
| 4186 | 217643 | 804.0 | Рыбинск | Ж | 40.0 | 2 | 301836.65 | 2 | 1 | 1 | 81137.22 | 0 |
| 1970 | 217643 | 845.0 | Ярославль | М | 29.0 | 5 | 733098.45 | 4 | 1 | 1 | 41443.01 | 1 |
| 9406 | 217826 | 790.0 | Рыбинск | Ж | 46.0 | 4 | 430414.80 | 2 | 1 | 1 | 49400.37 | 0 |
| 4623 | 217826 | 842.0 | Ярославль | М | 39.0 | 4 | 439747.65 | 2 | 0 | 1 | 116736.75 | 1 |
| 2302 | 220816 | 934.0 | Рыбинск | Ж | 74.0 | 5 | 1294285.39 | 3 | 1 | 0 | 95606.80 | 0 |
| 1740 | 220816 | 840.0 | Ярославль | Ж | 41.0 | 3 | 1193287.13 | 2 | 1 | 1 | 76434.94 | 1 |
| 4216 | 226719 | 903.0 | Рыбинск | Ж | 63.0 | 0 | NaN | 1 | 1 | 0 | 138582.58 | 0 |
| 2597 | 226719 | 990.0 | Ярославль | М | 37.0 | 4 | 14648692.14 | 2 | 0 | 0 | 934412.61 | 1 |
| 8497 | 227795 | 839.0 | Ярославль | М | 34.0 | 2 | 326593.14 | 2 | 1 | 0 | 103314.92 | 0 |
| 8205 | 227795 | 840.0 | Рыбинск | М | 34.0 | 2 | 350768.03 | 1 | 1 | 0 | 102036.14 | 1 |
Бросается в глаза, что дубликаты образуют пары с двумя разными значениями в столбце city. Проверим это.
pd.set_option('display.max_rows', 10)
#количество пар id, у которых различные значения столбца "city"
display(sum(id_dups.groupby('userid')['city'].nunique() > 1))
#названия городов
id_dups['city'].unique()
44
array(['Ярославль', 'Рыбинск'], dtype=object)
Так и есть: во всех парах с дублирующимся userid различаются города - Ярославль и Рыбинск.
Возможные причины появления таких дубликатов:
- Разным клиентам в разных городах по ошибке присвоены одинаковые userid (несовершенство системы сбора данных при работе с ней нескольких филиалов). Такие строки удалять не стоит, в них содержится информация о разных клиентах. Но в этом случае при дальнейшем анализе нужно будет учитывать, что userid в датасете могут быть не уникальны.
- Один и тот же клиент попал в базу дважды, в разных городах - например, не был удалён из базы при смене обслуживающего филиала, либо был дважды внесён в базу по ошибке. Такие дубликаты нужно удалить. В этом случае в строках будут дублироваться не только userid, но и пол, и баллы кредитного скоринга. Это можно проверить.
id_dups[id_dups[['userid','gender','score']].duplicated(keep=False)]
| userid | score | city | gender | age | equity | balance | products | credit_card | last_activity | est_salary | churn |
|---|
Таких клиентов нет.
Вывод: клиенты с дублирующимися userid - это разные клиенты из разных городов, которым по ошибке присвоены одинаковые userid (несовершенство системы сбора данных при работе с ней нескольких филиалов). Такие строки удалять не стоит, в них содержится информация о разных клиентах. Но в этом случае при дальнейшем анализе нужно будет учитывать, что userid в датасете могут быть не уникальны, однако доля таких клиентов очень мала (менее 0,5%).
Преобразование типов данных¶
df[['age','score']] = df[['age','score']].astype(int)
Кодирование категориальных переменных¶
df[['city_','gender_']] = df[['city','gender']]
df = pd.get_dummies(df, columns=['city_','gender_'], dtype='int')
df.head()
| userid | score | city | gender | age | equity | balance | products | credit_card | last_activity | est_salary | churn | city__Ростов | city__Рыбинск | city__Ярославль | gender__Ж | gender__М | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 183012 | 850 | Рыбинск | Ж | 25 | 1 | 59214.82 | 2 | 0 | 1 | 75719.14 | 1 | 0 | 1 | 0 | 1 | 0 |
| 1 | 146556 | 861 | Рыбинск | Ж | 37 | 5 | 850594.33 | 3 | 1 | 0 | 86621.77 | 0 | 0 | 1 | 0 | 1 | 0 |
| 2 | 120722 | 892 | Рыбинск | Ж | 30 | 0 | NaN | 1 | 1 | 1 | 107683.34 | 0 | 0 | 1 | 0 | 1 | 0 |
| 3 | 225363 | 866 | Ярославль | Ж | 51 | 5 | 1524746.26 | 2 | 0 | 1 | 174423.53 | 1 | 0 | 0 | 1 | 1 | 0 |
| 4 | 157978 | 730 | Ярославль | М | 34 | 5 | 174.00 | 1 | 1 | 0 | 67353.16 | 1 | 0 | 0 | 1 | 0 | 1 |
Проверка значения столбцов на предмет явных ошибок и аномалий.¶
for col in ['equity',
'products',
'credit_card',
'last_activity',
'churn']:
display(f'Значения столбца {col}:',df[col].value_counts(),'')
'Значения столбца equity:'
equity 0 2248 5 1915 4 1847 3 1540 2 1051 1 659 6 161 7 80 8 17 9 13 Name: count, dtype: int64
''
'Значения столбца products:'
products 2 4804 1 3225 3 1009 4 474 5 19 Name: count, dtype: int64
''
'Значения столбца credit_card:'
credit_card 1 6427 0 3104 Name: count, dtype: int64
''
'Значения столбца last_activity:'
last_activity 1 4983 0 4548 Name: count, dtype: int64
''
'Значения столбца churn:'
churn 0 7717 1 1814 Name: count, dtype: int64
''
Явно ошибочных значений в столбцах нет.
Проверка распределения количественных переменных на наличие аномалий и выбросов.¶
Столбец balance¶
description(df,'balance')
'Основные характеристики столбца'
count 7.695000e+03 mean 8.272456e+05 std 1.980327e+06 min 0.000000e+00 25% 2.956987e+05 50% 5.242953e+05 75% 9.800512e+05 max 1.191136e+08 Name: balance, dtype: float64
Распределение включает выбросы из большого количества редких высоких значений и сильно скошено в меньшую сторону относительно нормального, что смещает среднее в сторону больших значений. Оценим отдельно распределение значений больше и меньше расчётной верхней границы выбросов.
Распределение основной массы значений.
#верхняя граница выбросов
lim = q_iqr(df['balance'].dropna())[4]
#гистограмма, диаграмма размаха и основные характеристики
description(df[df['balance'] < lim], 'balance')
'Основные характеристики столбца'
count 7.268000e+03 mean 6.137157e+05 std 4.308430e+05 min 0.000000e+00 25% 2.864130e+05 50% 4.885574e+05 75% 8.723392e+05 max 1.999799e+06 Name: balance, dtype: float64
Распределение основной массы значений баланса средств укладывается в границы от 0 до 20 млн, сильно скошено в меньшую сторону относительно нормального. Среднее значение смещено к большим значениям относительно медианного.
Распределение высоких значений.
#гистограмма, диаграмма размаха и основные характеристики
description(df[df['balance'] > lim], 'balance', bins=200)
'Основные характеристики столбца'
count 4.270000e+02 mean 4.461755e+06 std 7.324261e+06 min 2.009137e+06 25% 2.355243e+06 50% 2.917344e+06 75% 4.090911e+06 max 1.191136e+08 Name: balance, dtype: float64
Распределение аномально высоких значений находится в границах от 20 до 119 млн. Поскольку в столбце содержатся актуальные данные о балансе клиентов, можно предположить, что речь идёт об очень состоятельных клиентах, которые держат значительные средства в виде вкладов и других продуктах банка, т.е. данные значения не являются выбросами, а просто относятся к сегменту элитных клиентов банка. Удалять такие значения не стоит, но поскольку такие данные вносят искажают характеристики распределения, при анализе по данному столбцу лучше использовать медиану вместо среднего.
Столбец score¶
#гистограмма, диаграмма размаха и основные характеристики
description(df, 'score',bins=50)
'Основные характеристики столбца'
count 9531.000000 mean 848.029798 std 63.845105 min 642.000000 25% 802.000000 50% 851.000000 75% 897.000000 max 1000.000000 Name: score, dtype: float64
Распределение значений близко к нормальному, среднее и медиана почти не отличаются, стандартное отклонение небольшое. Ярко выраженного пика распределения нет, возможно, их несколько.
Выделяется группа клиентов с рейтингом около 650. Изучим их более внимательно.
#гистограмма, диаграмма размаха и основные характеристики
description(df[df['score'] < 675], 'score', bins=10)
'Основные характеристики столбца'
count 67.000000 mean 656.432836 std 6.895813 min 642.000000 25% 652.000000 50% 655.000000 75% 661.000000 max 673.000000 Name: score, dtype: float64
67 клиентов, чей балл кредитного скоринга распределён примерно нормально. Вероятно, этих клиентов можно выделить в отдельную группу и изучить на этапе исследовательского анализа.
Аномалий и выбросов в столбце нет.
Столбец age¶
#гистограмма, диаграмма размаха и основные характеристики
description(df, 'age', bins=df['age'].nunique())
'Основные характеристики столбца'
count 9531.000000 mean 42.748925 std 12.205301 min 18.000000 25% 33.000000 50% 40.000000 75% 51.000000 max 86.000000 Name: age, dtype: float64
Распределение близко к нормальному, скошено в меньшую сторону. Возраст клиентов в датасете от 18 до 86 лет. Ошибок и аномалий нет.
Столбец est_salary¶
#гистограмма, диаграмма размаха и основные характеристики
description(df, 'est_salary')
'Основные характеристики столбца'
count 9.531000e+03 mean 1.438294e+05 std 1.335287e+05 min 2.546300e+03 25% 7.410336e+04 50% 1.175064e+05 75% 1.713289e+05 max 1.395064e+06 Name: est_salary, dtype: float64
Распределение скошено в меньшую сторону и имеет аномально редкие высокие значения в диапазоне более 400000. Изучим эту часть датафрейма.
df[df['est_salary'] > 400000]
| userid | score | city | gender | age | equity | balance | products | credit_card | last_activity | est_salary | churn | city__Ростов | city__Рыбинск | city__Ярославль | gender__Ж | gender__М | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 44 | 210313 | 935 | Ярославль | М | 41 | 5 | 3781118.22 | 2 | 1 | 0 | 761349.20 | 0 | 0 | 0 | 1 | 0 | 1 |
| 107 | 134358 | 985 | Рыбинск | Ж | 41 | 4 | 6749773.07 | 2 | 1 | 0 | 698013.27 | 0 | 0 | 1 | 0 | 1 | 0 |
| 124 | 192966 | 686 | Рыбинск | Ж | 33 | 0 | NaN | 1 | 1 | 1 | 518488.59 | 0 | 0 | 1 | 0 | 1 | 0 |
| 125 | 227092 | 1000 | Ярославль | М | 32 | 5 | 19757180.85 | 2 | 1 | 1 | 1024626.50 | 0 | 0 | 0 | 1 | 0 | 1 |
| 137 | 163907 | 761 | Ярославль | М | 31 | 0 | NaN | 1 | 1 | 0 | 999177.17 | 0 | 0 | 0 | 1 | 0 | 1 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9751 | 123478 | 693 | Ярославль | М | 41 | 0 | NaN | 1 | 1 | 1 | 667513.52 | 0 | 0 | 0 | 1 | 0 | 1 |
| 9862 | 102186 | 913 | Ярославль | М | 48 | 4 | 4203148.67 | 4 | 1 | 1 | 917628.11 | 0 | 0 | 0 | 1 | 0 | 1 |
| 9908 | 211089 | 993 | Рыбинск | М | 38 | 6 | 4542980.70 | 1 | 1 | 1 | 491609.13 | 1 | 0 | 1 | 0 | 0 | 1 |
| 9964 | 148232 | 968 | Ярославль | Ж | 32 | 0 | NaN | 1 | 1 | 0 | 520631.22 | 0 | 0 | 0 | 1 | 1 | 0 |
| 9984 | 125941 | 729 | Ярославль | Ж | 42 | 0 | NaN | 1 | 1 | 1 | 687538.70 | 0 | 0 | 0 | 1 | 1 | 0 |
288 rows × 17 columns
Проверим, не отличаются ли эти выбросы процентом оттока.
display(
'Средний процент оттока среди выбросов',
round(df[df['est_salary'] > 400000]['churn'].mean() * 100, 2)
)
display(
'Общий процент оттока',
round(df['churn'].mean() * 100, 2)
)
'Средний процент оттока среди выбросов'
17.36
'Общий процент оттока'
19.03
Средний процент оттока аномальных клиентов отличается от общего слабо.
Поскольку в данном столбце расчётные данные, их ценность для анализа не так высока, как у эмпирических, к тому же высока вероятность, что такие выбросы содержат большую долю ошибок. Выбросов мало (3%), и их можно удалить.
#удаление
df = df.query('est_salary < 400000')
#гистограмма, диаграмма размаха и основные характеристики
description(df,'est_salary')
'Основные характеристики столбца'
count 9243.000000 mean 125054.571187 std 69766.262551 min 2546.300000 25% 72730.660000 50% 115073.100000 75% 165694.795000 max 399043.430000 Name: est_salary, dtype: float64
Распределение близко к нормальному, скошено в меньшую сторону, среднее достаточно близко к медианному, стандартное отклонение почти вдвое меньше, чем в наборе данных до фильтрации выбросов.
Вывод по разделу предобработки данных¶
Датафрейм после предобработки
#основная информация
df.info()
#процент удалённых пропусков
del_rate = (df_copy.shape[0] - df.shape[0]) * 100 / df_copy.shape[0]
display(f'Процент удалённых данных {del_rate:.2f}')
<class 'pandas.core.frame.DataFrame'> Index: 9243 entries, 0 to 9999 Data columns (total 17 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 userid 9243 non-null int64 1 score 9243 non-null int32 2 city 9243 non-null object 3 gender 9243 non-null object 4 age 9243 non-null int32 5 equity 9243 non-null int64 6 balance 7576 non-null float64 7 products 9243 non-null int64 8 credit_card 9243 non-null int64 9 last_activity 9243 non-null int64 10 est_salary 9243 non-null float64 11 churn 9243 non-null int64 12 city__Ростов 9243 non-null int32 13 city__Рыбинск 9243 non-null int32 14 city__Ярославль 9243 non-null int32 15 gender__Ж 9243 non-null int32 16 gender__М 9243 non-null int32 dtypes: float64(2), int32(7), int64(6), object(2) memory usage: 1.0+ MB
'Процент удалённых данных 7.57'
Данные загружены, названия столбцов приведены к стилю snake_case.
Пропуски в стобце age удалены. Пропуски в столбце balance классифицированы как случайные (MAR) и частично удалены, выделена группа клиентов с высоким содержанием пропусков (80,56%) с баллом собственности 0 и количеством продуктов 1. Возможно, речь идёт о группе малоимущих клиентов либо клиентов, которые мало пользуются продуктами банка - причину пропусков можно попробовать выяснить у заказчика, в любом случае на это стоит обратить его внимание.
Пропуски для групп клиентов с количеством продуктов более 1 и баллом собственности более 1 удалены ввиду их малого количества.
Явные дубликаты не обнаружены, неявные дубликаты изучены. Клиенты с дублирующимися userid - это, по-видимому, разные клиенты из разных городов (Ярославль и Рыбинск), которым по ошибке присвоены одинаковые userid (вероятно, несовершенство системы сбора данных при работе с ней нескольких филиалов). При дальнейшем анализе нужно учесть, что userid в датасете могут быть не уникальны.
Категориальные переменные стобцов city и gender закодированы.
Удалены выбросы в столбце est_salary.
В процессе предобработки удалены 7,57% данных.
Исследовательский анализ данных¶
Общий процент уходящих клиентов.¶
total_ch_rate = round(df['churn'].mean() * 100, 2)
total_ch_rate
19.08
Общий процент уходящих клиентов в датсете 19,08%.
Портреты типичного остающегося и уходящего клиентов¶
Средние и медианные значения уходящих и остающихся клиентов по количественным признакам.¶
avg_quant = df.pivot_table(
index='churn',
values=[
'score',
'age',
'balance',
'est_salary'
],
aggfunc={
'score':['mean','median'],
'age':'median',
'balance':['mean','median'],
'est_salary':['mean','median']
}
).T
avg_quant.columns = ['nonchurn','churn']
avg_quant['total']= pd.Series(
data=[
df['age'].median(),
df['balance'].mean(),
df['balance'].median(),
df['est_salary'].mean(),
df['est_salary'].median(),
df['score'].mean(),
df['score'].median()
]
).values
avg_quant['churn_nonchurn_diff_%'] = \
(avg_quant['churn'] - avg_quant['nonchurn']) * 100 / avg_quant['nonchurn']
avg_quant['churn_total_diff_%'] = \
(avg_quant['churn'] - avg_quant['total']) * 100 / avg_quant['total']
avg_quant['nonchurn_total_diff_%'] = \
(avg_quant['nonchurn'] - avg_quant['total']) * 100 / avg_quant['total']
avg_quant = round(avg_quant, 1)#.map('{:.1f}'.format)
avg_quant
| nonchurn | churn | total | churn_nonchurn_diff_% | churn_total_diff_% | nonchurn_total_diff_% | ||
|---|---|---|---|---|---|---|---|
| age | median | 40.0 | 39.0 | 40.0 | -2.5 | -2.5 | 0.0 |
| balance | mean | 673232.1 | 1017219.3 | 753099.2 | 51.1 | 35.1 | -10.6 |
| median | 471128.8 | 765990.0 | 515461.0 | 62.6 | 48.6 | -8.6 | |
| est_salary | mean | 123337.1 | 132336.4 | 125054.6 | 7.3 | 5.8 | -1.4 |
| median | 112637.8 | 122407.5 | 115073.1 | 8.7 | 6.4 | -2.1 | |
| score | mean | 843.4 | 861.7 | 846.9 | 2.2 | 1.7 | -0.4 |
| median | 844.0 | 866.0 | 849.0 | 2.6 | 2.0 | -0.6 |
Сравнение типичных пользователей по категориальным признакам.¶
typical_categorial = df.groupby('churn')[[
'city',
'gender',
'equity',
'products',
'credit_card',
'last_activity']].agg(
{
'city':pd.Series.mode,
'gender':pd.Series.mode,
'equity':'median',
'products':'median',
'credit_card':'median',
'last_activity':'median'
}
).T
typical_categorial
| churn | 0 | 1 |
|---|---|---|
| city | Ярославль | Ярославль |
| gender | Ж | М |
| equity | 3.0 | 4.0 |
| products | 2.0 | 2.0 |
| credit_card | 1.0 | 1.0 |
| last_activity | 0.0 | 1.0 |
Различия и сходства портретов типичных пользователей.¶
Типичный уходящий клиент:
- медианный возраст 39 лет;
- средний баланс на счёте 1017219,3, медианный 765990;
- средний оценочный доход 132336,4, медианный 122407,5;
- средний балл кредитного скоринга 861,7, медианный 849;
- город - Ярославль;
- пол мужской;
- медианный балл собственности 5;
- медианное количество продуктов 2;
- есть кредитная карта;
- проявлял активность в последнее время.
Типичный остающийся клиент:
- медианный возраст 40 лет;
- средний баланс на счёте 673232.1, медианный 471128.8;
- средний оценочный доход 123337.1, медианный 112637.8;
- средний балл кредитного скоринга 843.4, медианный 844.0;
- город - Ярославль;
- пол женский;
- медианный балл собственности 3;
- медианное количество продуктов 2;
- есть кредитная карта;
- не проявлял активность в последнее время.
Сходства в портретах типичного уходящего и остающегося клиентов:
- наличие кредитной карты;
- количество продуктов: 2.
Различия в портретах типичного уходящего и остающегося клиентов:
- средний баланс уходящего клиента выше на 51,1% (медианный - на 62,6%);
- среди уходящих клиентов больше мужчин, среди остающихся - женщин;
- медианное количество продуктов у уходящих клиентов больше, чем у остающихся.
- оценочный доход уходящего клиента выше на 7,3% (медианный - на 8,7%);
Остальные признаки различаются, но незначительно.
Анализ распределения непрерывных и дискретных переменных для остающихся и уходящих клиентов¶
Столбец balance¶
Для удобства представления рассмотрим отдельно клиентов с обычным и аномально большим балансом.
#граница совместного распределения клиентов с аномально большими доходами
lim=q_iqr(df.dropna()['balance'])[4]
#график клиентов с обычными доходами
fig = px.histogram(
df[df['balance'] < lim],
x='balance',
width=1400,
height=1000,
color='churn',
nbins=100,
color_discrete_sequence=['steelblue','grey'],
histnorm='probability density',
barmode='overlay',
labels={'churn':'Признак оттока'},
title='Распределение значений баланса уходящих и остающихся клиентов с обычным балансом'
)
fig.update_xaxes(
title_text='Значения'
)
fig.update_yaxes(
title_text='Частота'
)
new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))
fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))
#сохранение изображения
plt_name = pics_path + 'Распределение значений баланса уходящих и остающихся клиентов с обычным балансом.png'
fig.write_image(
plt_name,
width=1400,
height=1000
)
fig.show()
#график совместного распределения клиентов с обычными доходами
fig = px.histogram(
df[df['balance'] > lim],
x='balance',
width=1400,
height=1000,
color='churn',
nbins=100,
color_discrete_sequence=['grey','steelblue'],
histnorm='probability density',
barmode='overlay',
labels={'churn':'Признак оттока'},
title='Распределение значений баланса уходящих и остающихся клиентов с высоким балансом'
)
fig.update_xaxes(
title_text='Значения'
)
fig.update_yaxes(
title_text='Частота'
)
new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))
fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))
#сохранение изображения
plt_name = pics_path + 'Распределение значений баланса уходящих и остающихся клиентов с высоким балансом.png'
fig.write_image(
plt_name,
width=1400,
height=1000
)
fig.show('png')
Можно выделить следующие достаточно большие сегменты с повышенным оттоком клиентов:
- клиенты с балансом 750000-870000;
- клиенты с балансом 890000 - 1,07 млн;
- клиенты с балансом 1,1 млн - 2 млн.
Оценим численность этих сегментов и превышение их оттока относительно среднего показателя.
#границы сегментов
balance_seg_borders = {
'seg_a':[750000,870000],
'seg_b':[890000,1070000],
'seg_c':[1100000,2000000],
}
#датафрейм с агрегированной информацией по по столбцу
balance_segments = segmentation(balance_seg_borders, df, 'balance','баланс на счёте', total_ch_rate)
balance_segments
| churn_rate | churn_to_total_churn_diff_% | segment_size | segment | |
|---|---|---|---|---|
| seg_c | 38.02 | 99.28 | 1173 | balance_1100000-2000000 |
| seg_a | 26.68 | 39.84 | 446 | balance_750000-870000 |
| seg_b | 26.35 | 38.09 | 501 | balance_890000-1070000 |
Клиенты с более высоким балансом более склонны к уходу. Представляют интерес клиенты с балансом 1,1-2 млн. Средний процент оттока у таких клиентов превышает общий показатель почти вдвое.
Столбец est_salary¶
#график совместного распределения
fig = px.histogram(
df,
x='est_salary',
width=1400,
height=1000,
color='churn',
nbins=100,
color_discrete_sequence=['steelblue','grey'],
histnorm='probability density',
barmode='overlay',
labels={'churn':'Признак оттока'},
title='Распределение значений дохода уходящих и остающихся клиентов'
)
fig.update_xaxes(
title_text='Значения'
)
fig.update_yaxes(
title_text='Частота'
)
new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))
fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))
#сохранение изображения
plt_name = pics_path + 'Распределение значений дохода уходящих и остающихся клиентов.png'
fig.write_image(
plt_name,
width=1400,
height=1000
)
fig.show()
Можно выделить следующие достаточно большие сегменты с повышенным оттоком клиентов:
- клиенты с доходом 100000 - 135000;
- клиенты с доходом 150000 - 180000;
- клиенты с доходом 185000 - 215000.
Оценим численность этих сегментов и превышение их оттока относительно среднего показателя.
#границы сегментов
salary_seg_borders = {
'seg_a':[100000,135000],
'seg_b':[150000,180000],
'seg_c':[185000,215000],
}
#датафрейм с агрегированной информацией по по столбцу
salary_segments = segmentation(salary_seg_borders, df, 'est_salary', 'оценочный доход', total_ch_rate)
salary_segments
| churn_rate | churn_to_total_churn_diff_% | segment_size | segment | |
|---|---|---|---|---|
| seg_c | 23.16 | 21.38 | 652 | est_salary_185000-215000 |
| seg_a | 21.87 | 14.62 | 1765 | est_salary_100000-135000 |
| seg_b | 21.67 | 13.57 | 1126 | est_salary_150000-180000 |
Оценочный средний доход мало влияет на отток: в интервале значений с самым высоким средним процентом оттока этот процент не превышает общий показатель более чем на 21,4%.
Столбец score¶
#график совместного распределения
fig = px.histogram(
df,
x='score',
width=1400,
height=1000,
color='churn',
nbins=100,
color_discrete_sequence=['steelblue','grey'],
histnorm='probability density',
barmode='overlay',
labels={'churn':'Признак оттока'},
title='Распределение значений балла кредитного скоринга уходящих и остающихся клиентов'
)
fig.update_xaxes(
title_text='Значения'
)
fig.update_yaxes(
title_text='Частота'
)
new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))
fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))
#сохранение изображения
plt_name = pics_path + 'Распределение значений балла кредитного скоринга уходящих и остающихся клиентов.png'
fig.write_image(
plt_name,
width=1400,
height=1000
)
fig.show()
Можно выделить следующие достаточно большие сегменты с повышенным оттоком клиентов:
- клиенты с баллом 835 - 909;
- клиенты с баллом 920 - 944;
Оценим численность этих сегментов и превышение их оттока относительно среднего показателя.
#границы сегментов
score_seg_borders = {
'seg_a':[850,890],
'seg_b':[920,934]
}
#датафрейм с агрегированной информацией по по столбцу
score_segments = segmentation(score_seg_borders, df, 'score', 'балл скоринга', total_ch_rate)
score_segments
| churn_rate | churn_to_total_churn_diff_% | segment_size | segment | |
|---|---|---|---|---|
| seg_a | 28.49 | 49.31 | 2043 | score_850-890 |
| seg_b | 24.58 | 28.81 | 472 | score_920-934 |
Можно выделить интервал значений 850-890: средний процент оттока клиентов с таким рейтингом превышает общий показатель на 49,3%.
Столбец age¶
#график совместного распределения
fig = px.histogram(
df,
x='age',
width=1400,
height=1000,
color='churn',
nbins=df['age'].nunique()*2,
color_discrete_sequence=['steelblue','grey'],
histnorm='probability density',
barmode='overlay',
labels={'churn':'Признак оттока'},
title='Распределение значений балла кредитного скоринга уходящих и остающихся клиентов'
)
fig.update_xaxes(
title_text='Значения'
)
fig.update_yaxes(
title_text='Частота'
)
new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))
fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))
#сохранение изображения
plt_name = pics_path + 'Распределение значений возраста уходящих и остающихся клиентов.png'
fig.write_image(
plt_name,
width=1400,
height=1000
)
fig.show()
Можно выделить следующие достаточно большие сегменты с повышенным оттоком клиентов:
- клиенты в возрасте 25-29 лет;
- клиенты в возрасте 31-34 лет;
- клиенты в возрасте 51-60 лет;
Оценим численность этих сегментов и превышение их оттока относительно среднего показателя.
#границы сегментов
age_seg_borders = {
'seg_a':[25,29],
'seg_b':[31,34],
'seg_c':[52,59]
}
#датафрейм с агрегированной информацией по по столбцу
age_segments = segmentation(age_seg_borders, df, 'age', 'возраст', total_ch_rate)
age_segments
| churn_rate | churn_to_total_churn_diff_% | segment_size | segment | |
|---|---|---|---|---|
| seg_c | 30.69 | 60.84 | 1147 | age_52-59 |
| seg_a | 26.45 | 38.63 | 862 | age_25-29 |
| seg_b | 21.42 | 12.25 | 1354 | age_31-34 |
Наиболее интересный интервал значений возраста - 52-59 лет, клиенты этого возраста имеют средний процент оттока на 60,8% выше общего показателя.
Процент уходящих клиентов по категориальным признакам и влияние этих признаков на отток¶
Сводные таблицы и визуализация¶
#список категориальных признаков
cat_cols = {
'city':'город',
'gender':'пол',
'equity':'количество баллов собственности',
'products':'количество продуктов',
'credit_card':'кред. карта',
'last_activity':'активность'
}
for col in cat_cols.keys():
#создание сводной таблицы
pivot = pd.DataFrame(df.groupby(col)['churn'].mean()*100)
pivot['churn_rate_to_total_diff_%'] = pivot.apply(lambda x: (x / total_ch_rate - 1) *100)
pivot['churn'] = pivot['churn'].map('{:.2f}'.format).astype('float')
pivot['churn_rate_to_total_diff_%'] = (
pivot['churn_rate_to_total_diff_%']
.map('{:.2f}'.format)
.astype('float')
)
pivot = pivot.sort_values(by='churn', ascending=False).reset_index()
pivot.columns=[col,'churn_rate','churn_rate_to_total_diff_%']
pivot['segment_size'] = pivot[col].map(lambda x: df[df[col] == x]['churn'].count())
pivot = pivot.sort_values(by='churn_rate', ascending=False)
display(f'Процент оттока для столбца {col}',pivot,'')
#построение диаграммы
plt.figure(figsize=(15,len(pivot[col].unique())))
ax = sns.barplot(
pivot,
x='churn_rate',
y=col,
orient='y',
order= pivot.sort_values(by='churn_rate', ascending=False)[col]
)
plt.axvline(
total_ch_rate,
linestyle='--',
color='red',
label='Общий процент оттока'
)
plt.legend(loc='upper center')
ax.bar_label(ax.containers[0], fontsize=12)
ax.set_title(f'Процент оттока по: {col}')
ax.set_xlabel('Процент оттока')
ax.set_ylabel(f'Признак: {col}');
#сохранение изображения
plt_name = pics_path + 'Процент оттока по признаку ' + cat_cols[col] + '.png'
plt.savefig(
plt_name,
bbox_inches= 'tight'
)
plt.show();
display('','')
'Процент оттока для столбца city'
| city | churn_rate | churn_rate_to_total_diff_% | segment_size | |
|---|---|---|---|---|
| 0 | Ярославль | 19.99 | 4.74 | 5379 |
| 1 | Ростов | 19.08 | -0.02 | 1363 |
| 2 | Рыбинск | 17.15 | -10.10 | 2501 |
''
''
''
'Процент оттока для столбца gender'
| gender | churn_rate | churn_rate_to_total_diff_% | segment_size | |
|---|---|---|---|---|
| 0 | М | 25.0 | 31.03 | 4568 |
| 1 | Ж | 13.3 | -30.27 | 4675 |
''
''
''
'Процент оттока для столбца equity'
| equity | churn_rate | churn_rate_to_total_diff_% | segment_size | |
|---|---|---|---|---|
| 0 | 9 | 53.85 | 182.21 | 13 |
| 1 | 7 | 45.57 | 138.83 | 79 |
| 2 | 8 | 35.29 | 84.98 | 17 |
| 3 | 6 | 35.03 | 83.61 | 157 |
| 4 | 5 | 29.49 | 54.58 | 1858 |
| 5 | 4 | 25.03 | 31.17 | 1818 |
| 6 | 3 | 20.71 | 8.54 | 1521 |
| 7 | 2 | 15.92 | -16.58 | 1043 |
| 8 | 1 | 14.11 | -26.04 | 659 |
| 9 | 0 | 3.99 | -79.07 | 2078 |
''
''
''
'Процент оттока для столбца products'
| products | churn_rate | churn_rate_to_total_diff_% | segment_size | |
|---|---|---|---|---|
| 0 | 4 | 62.75 | 228.85 | 459 |
| 1 | 5 | 42.11 | 120.68 | 19 |
| 2 | 3 | 28.97 | 51.85 | 994 |
| 3 | 2 | 20.16 | 5.69 | 4731 |
| 4 | 1 | 7.43 | -61.04 | 3040 |
''
''
''
'Процент оттока для столбца credit_card'
| credit_card | churn_rate | churn_rate_to_total_diff_% | segment_size | |
|---|---|---|---|---|
| 0 | 0 | 26.21 | 37.35 | 3026 |
| 1 | 1 | 15.62 | -18.14 | 6217 |
''
''
''
'Процент оттока для столбца last_activity'
| last_activity | churn_rate | churn_rate_to_total_diff_% | segment_size | |
|---|---|---|---|---|
| 0 | 1 | 25.56 | 33.97 | 4816 |
| 1 | 0 | 12.04 | -36.90 | 4427 |
''
''
''
Выводы и наблюдения.¶
Признак city слабо влияет на отток клиентов. Средний процент уходящих клиентов несколько ниже среди клиентов из Рыбинска, равный общему в Ростове, выше - в Ярославле, но отличие от среднего значения невелико.
Признак gender существенно влияет на отток клиентов: процент уходящих клиентов среди мужчин почти вдвое выше, чем среди женщин. Тем не менее, процент уходящих клиентов среди мужчин всего на 31% выше, чем общий показатель по датасету.
То же самое можно сказать о признаке credit_card. Среди клиентов с кредитной картой значительно меньше уходящих, но их средний процент оттока всего на 37,3% выше, чем у клиентов с кредитной картой.
Признак last_activity существенно влияет на отток клиентов: средний процент уходящих клиентов среди активных клиентов вдовое превышает показатель для клиентов без признака. Тем не менее, средний процент уходящих клиентов среди активных всего на 33,9% выше, общий показатель по датасету.
Отток клиентов зависит от числа продуктов (products). Чем больше у клиентов продуктов, тем выше среди них процент оттока. Процент оттока среди клиентов:
- с количеством продуктов 3 на 52% выше среднего;
- с количеством продуктов 4 на 228% выше среднего;
- с количеством продуктов 5 на 120% выше среднего; однако таких клиентов всего 19, имеет смысл объединить их с клиентами из предыдущего интервала.
Отток клиентов зависит от балла собственности (equity). Чем выше балл собственности у клиентов, тем выше среди них процент оттока. Можно выделить следующие категории клиентов с высоким оттоком:
- клиенты с баллом собственности 5: процент оттока на 54,5% выше среднего;
- клиенты с баллом собственности 6 и более: процент оттока выше среднего 83,6%-182,1%, сегменты малочисленные, имеет смысл объединить их в один.
Корреляционный анализ¶
Матрица коэффициентов корреляций Пирсона для всего датафрейма и столбца churn.¶
#матрица корреляций
corr_matrix = df[[
'score',
'age',
'equity',
'balance',
'products',
'credit_card',
'last_activity',
'est_salary',
'churn',
'city__Ростов',
'city__Рыбинск',
'city__Ярославль',
'gender__Ж',
'gender__М']].corr()
#построение тепловой карты
fig, axs = plt.subplots(1, 2, figsize=(15,10), width_ratios=[4,1])
sns.heatmap(
corr_matrix,
annot=True,
cmap='coolwarm',
fmt='.2f',
center=0,
ax=axs[0]
);
axs[0].set_ylabel('Столбцы')
axs[0].set_xlabel('Столбцы')
axs[0].set_title('Коэффициенты корреляций Пирсона для всего датафрейма')
sns.heatmap(
(corr_matrix['churn']
.drop('churn')
.sort_values(ascending=False)
.to_frame()),
annot=True,
cmap='coolwarm',
fmt='.2f',
center=0,
ax=axs[1]
);
axs[1].set_ylabel('Столбцы')
axs[1].set_xlabel('Признак оттока')
axs[1].set_title('Столбец "сhurn"')
fig.show();
Нет достаточно сильных взаимосвязей ни между столбцами датафрейма вообще, ни между столбцом churn и значениями переменных.
Матрица коэффициентов корреляций с использованием библиотеки phik для всего датафрейма и столбца churn.¶
#матрица корреляций
phik_corr_matrix = df[[
'score',
'age',
'equity',
'balance',
'products',
'credit_card',
'last_activity',
'est_salary',
'churn',
'city__Ростов',
'city__Рыбинск',
'city__Ярославль',
'gender__Ж',
'gender__М'
]].phik_matrix(
interval_cols=['balance','est_salary'],
bins={'balance':100,'est_salary':100}
)
#построение тепловой карты
fig, axs = plt.subplots(1, 2, figsize=(15,10), width_ratios=[4,1])
sns.heatmap(
phik_corr_matrix,
annot=True,
cmap='coolwarm',
fmt='.2f',
center=0,
ax=axs[0]
);
axs[0].set_ylabel('Столбцы')
axs[0].set_xlabel('Столбцы')
axs[0].set_title('Коэффициенты корреляций для всего датафрейма')
sns.heatmap(
(phik_corr_matrix['churn']
.drop('churn')
.sort_values(ascending=False)
.to_frame()),
annot=True,
cmap='coolwarm',
fmt='.2f',
center=0,
ax=axs[1]
);
axs[1].set_ylabel('Столбцы')
axs[1].set_xlabel('Признак оттока')
axs[1].set_title('Столбец "сhurn"')
fig.show();
Нет достаточно сильной взаимосвязи между столбцом churn и значениями переменных.
Возможна умеренная взаимосвязь между значениями столбцов:
- balance и score (коэффициент 0,63);
- products и equity (коэффициент 0,63);
- balance и est_salary (коэффициент 0,65).
Возможна сильная взаимосвязь между значениями столбцов:
- est_salary и score (коэффициент 0,71).
Промежуточный вывод о признаках и интервалах значений, которые значимо влияют на отток клиентов¶
Интерес для анализа представляют следующие признаки и интервалы значений, которые значимо влияют на отток клиентов:
- клиенты с балансом 1,1-2 млн. Средний процент оттока у таких клиентов превышает общий показатель почти вдвое: на 99,2%; количество клиентов 1173;
- интервал значений 850-890: средний процент оттока клиентов с таким рейтингом превышает общий показатель на 49,3%; количество клиентов 2043;
- интервал значений возраста - 52-59 лет, клиенты этого возраста имеют средний процент оттока на 60,8% выше общего показателя; количество клиентов 1147;
- клиенты с количеством продуктов 3 на 52% выше среднего; количество клиентов 994;
- клиенты с количеством продуктов 4 на 228% выше среднего; количество клиентов 459;
- клиенты с количеством продуктов 5 на 120% выше среднего; количество клиентов 19;
- клиенты с баллом собственности 4: процент оттока на 31% выше среднего; количество клиентов 1818;
- клиенты с баллом собственности 5: процент оттока на 54,5% выше среднего; количество клиентов 1858;
- клиенты с баллом собственности 6 и более: процент оттока выше среднего 83,6%-182,1%; количество клиентов 266;
- gender: среди мужчин почти вдвое выше, чем среди женщин. Тем не менее, процент уходящих клиентов среди мужчин всего на 31% выше, чем в среднем по датасету; количество клиентов 4568;
- credit_card: среди клиентов с кредитной картой значительно больше уходящих, но их процент оттока всего на 37,3% выше, чем у клиентов без кредитной карты; количество клиентов 3026;
- last_activity: средний процент уходящих клиентов среди активных клиентов вдвое превышает показатель для клиентов без признака. Тем не менее, средний процент уходящих клиентов среди активных всего на 33,9% выше, общий показатель по датасету; количество клиентов 4816.
Статистический анализ данных. Проверка статистических гипотез.¶
Гипотеза о различии дохода между теми клиентами, которые ушли и теми, которые остались.¶
Выбор статистического критерия.
Генеральные совокупности (остающиеся и уходящие клиенты) не зависят друг от друга, нет оснований считать их дисперсии различными. Распределение совокупностей достаточно близко к нормальному, выбросы удалены. Для проверки гипотезы можно воспользоваться тестом Стьюдента (t-тест) для сравнения средних значений двух генеральных совокупностей.
Формулирование гипотез.
Нулевая гипотеза H0: средний доход уходящих клиентов равен среднему доходу остающихся.
Алтернативная гипотеза H1: средний доход уходящих клиентов выше, чем остающихся.
Проведение теста.
#уровень статистической значимости
alpha = 0.05
#расчёт и вывод p-value
results = st.ttest_ind(
df[df['churn'] == 1]['est_salary'],
df[df['churn'] == 0]['est_salary'],
alternative='greater'
)
print(f'p-значение:, {results.pvalue}', '\n')
#результат
if results.pvalue < alpha:
print('Отвергаем нулевую гипотезу')
else:
print('Не получилось отвергнуть нулевую гипотезу')
print(
'\n',
'Средний доход уходящих клиентов: ',
round(df[df['churn'] == 1]['est_salary'].mean(), 2),
'\n',
'\n',
'Средний доход остающихся клиентов: ',
round(df[df['churn'] == 0]['est_salary'].mean(), 2),
)
p-значение:, 5.409357060500146e-07 Отвергаем нулевую гипотезу Средний доход уходящих клиентов: 132336.44 Средний доход остающихся клиентов: 123337.07
Вывод: есть основания считать, что средний доход уходящих клиентов выше, чем остающихся.
Также проверим гипотезу тестом Уилкинсона-Манна-Уитни.
#уровень статистической значимости
alpha = 0.05
#расчёт и вывод p-value
results = st.mannwhitneyu(
df[df['churn'] == 1]['est_salary'],
df[df['churn'] == 0]['est_salary'],
alternative='greater'
)
print(f'p-значение:, {results.pvalue}', '\n')
#результат
if results.pvalue < alpha:
print('Отвергаем нулевую гипотезу')
else:
print('Не получилось отвергнуть нулевую гипотезу')
print(
'\n',
'Средний доход уходящих клиентов: ',
round(df[df['churn'] == 1]['est_salary'].mean(), 2),
'\n',
'\n',
'Средний доход остающихся клиентов: ',
round(df[df['churn'] == 0]['est_salary'].mean(), 2),
)
p-значение:, 9.524632238669428e-09 Отвергаем нулевую гипотезу Средний доход уходящих клиентов: 132336.44 Средний доход остающихся клиентов: 123337.07
Оба теста показали один и тот же результат: есть основания считать, что средний доход уходящих клиентов выше, чем остающихся.
Проверить гипотезу о разнице балла кредитного скоринга уходящих и остающихся клиентов.¶
Выбор статистического критерия.
Генеральные совокупности (остающиеся и уходящие клиенты) не зависят друг от друга, нет оснований считать их дисперсии различными. Распределение совокупностей достаточно близко к нормальному, выбросы удалены. Для проверки гипотезы можно воспользоваться тестом Стьюдента (t-тест) для сравнения средних значений двух генеральных совокупностей.
Формулирование гипотез.
Нулевая гипотеза H0: средний балл кредитного скоринга уходящих клиентов равен среднему баллу кредитного скоринга остающихся.
Алтернативная гипотеза H1: средний балл кредитного скоринга уходящих клиентов выше среднего балла кредитного скоринга остающихся.
Проведение теста.
#уровень статистической значимости
alpha = 0.05
#расчёт и вывод p-value
results = st.ttest_ind(
df[df['churn'] == 1]['score'],
df[df['churn'] == 0]['score'],
alternative='greater'
)
print(f'p-значение:, {results.pvalue}', '\n')
#результат
if results.pvalue < alpha:
print('Отвергаем нулевую гипотезу')
else:
print('Не получилось отвергнуть нулевую гипотезу')
print(
'\n',
'Средний балл кредитного скоринга уходящих клиентов: ',
round(df[df['churn'] == 1]['score'].mean(), 2),
'\n',
'\n',
'Средний балл кредитного скоринга остающихся клиентов: ',
round(df[df['churn'] == 0]['score'].mean(), 2),
)
p-значение:, 5.99679738606846e-29 Отвергаем нулевую гипотезу Средний балл кредитного скоринга уходящих клиентов: 861.68 Средний балл кредитного скоринга остающихся клиентов: 843.37
Вывод: есть основания считать, что средний балл кредитного скоринга уходящих клиентов выше, чем остающихся.
Гипотеза о более высоком балансе уходящих клиентов относительно остающихся.¶
Выбор статистического критерия.
Генеральные совокупности (остающиеся и уходящие клиенты) не зависят друг от друга. Распределение совокупностей скошено, присутствуют выбросы. Для проверки гипотезы можно воспользоваться тестом Уилкоксона-Манна-Уитни для сравнения средних значений двух генеральных совокупностей.
Формулирование гипотез.
Нулевая гипотеза H0: средний баланс уходящих клиентов равен среднему балансу остающихся.
Алтернативная гипотеза H1: средний баланс уходящих клиентов выше среднего баланса остающихся.
Проведение теста.
#уровень статистической значимости
alpha = 0.05
#расчёт и вывод p-value
results = st.mannwhitneyu(
df[df['churn'] == 1]['balance'].dropna(),
df[df['churn'] == 0]['balance'].dropna(),
alternative='greater'
)
print('p-значение:', results.pvalue, '\n')
#результат
if results.pvalue < alpha:
print('Отвергаем нулевую гипотезу')
else:
print('Не получилось отвергнуть нулевую гипотезу')
print(
'\n',
'Средний баланс уходящих клиентов: ',
round(df[df['churn'] == 1]['balance'].mean(), 2),
'\n',
'\n',
'Средний баланс остающихся клиентов: ',
round(df[df['churn'] == 0]['balance'].mean(), 2),
)
p-значение: 2.009667769752643e-67 Отвергаем нулевую гипотезу Средний баланс уходящих клиентов: 1017219.31 Средний баланс остающихся клиентов: 673232.07
Вывод: есть основания считать, что средний баланс уходящих клиентов выше, чем остающихся.
Вывод по разделу статистического анализа данных¶
Результаты проверки гипотез.
Есть основания считать, что средний доход уходящих клиентов выше, чем остающихся.
Есть основания считать, что средний балл кредитного скоринга уходящих клиентов выше, чем остающихся.
Есть основания считать, что средний баланс уходящих клиентов выше, чем остающихся.
Сегментация клиентов, склонных к оттоку, на основе показателей, выявленных на этапе исследовательского анализа данных. Приоритизация и описание полученных сегментов.¶
Уточним показатели полученных в разделе исследовательского анализа данных интервалов, используя признаки gender, credit_card и last_activity, чьи значения среднего процента оттока распределены достаточно контрастно относительно общего значения оттока.
#список границ интервалов
conditions = [
'1100000 <= balance <= 2000000',
'850 <= score <= 890',
'52 <= age <= 59',
'products == 3',
'products >= 4',
'equity == 4',
'equity == 5',
'equity >= 6'
]
#список бинарных признаков
attributes_list = [
'gender',
'credit_card',
'last_activity'
]
#построение и вывод сводных таблиц процента уходящих клиентов в интервалах
for condition in conditions:
total=pd.DataFrame()
display(f'Интервал "{condition}":')
for attribute in attributes_list:
pivot = df.query(condition).groupby(attribute).agg({'churn':['mean','count']})
pivot.columns = ['churn_rate','segment_size']
pivot['churn_rate'] = round(pivot['churn_rate'] * 100, 1)
pivot['churn_to_total_diff_%'] = round((pivot['churn_rate'] / total_ch_rate - 1) * 100, 2)
pivot = pivot.reset_index()
pivot['index'] = pivot[attribute].map(lambda x: attribute + '_' + str(x))
pivot = pivot.set_index('index').drop(columns=[attribute])
total = pd.concat([total,pivot])
display(total,'')
'Интервал "1100000 <= balance <= 2000000":'
| churn_rate | segment_size | churn_to_total_diff_% | |
|---|---|---|---|
| index | |||
| gender_Ж | 28.2 | 592 | 47.80 |
| gender_М | 48.0 | 581 | 151.57 |
| credit_card_0 | 41.7 | 573 | 118.55 |
| credit_card_1 | 34.5 | 600 | 80.82 |
| last_activity_0 | 23.9 | 561 | 25.26 |
| last_activity_1 | 51.0 | 612 | 167.30 |
''
'Интервал "850 <= score <= 890":'
| churn_rate | segment_size | churn_to_total_diff_% | |
|---|---|---|---|
| index | |||
| gender_Ж | 21.9 | 1081 | 14.78 |
| gender_М | 35.9 | 962 | 88.16 |
| credit_card_0 | 36.1 | 815 | 89.20 |
| credit_card_1 | 23.5 | 1228 | 23.17 |
| last_activity_0 | 11.6 | 1040 | -39.20 |
| last_activity_1 | 46.0 | 1003 | 141.09 |
''
'Интервал "52 <= age <= 59":'
| churn_rate | segment_size | churn_to_total_diff_% | |
|---|---|---|---|
| index | |||
| gender_Ж | 25.1 | 650 | 31.55 |
| gender_М | 38.0 | 497 | 99.16 |
| credit_card_0 | 39.6 | 477 | 107.55 |
| credit_card_1 | 24.3 | 670 | 27.36 |
| last_activity_0 | 23.1 | 555 | 21.07 |
| last_activity_1 | 37.8 | 592 | 98.11 |
''
'Интервал "products == 3":'
| churn_rate | segment_size | churn_to_total_diff_% | |
|---|---|---|---|
| index | |||
| gender_Ж | 21.3 | 578 | 11.64 |
| gender_М | 39.7 | 416 | 108.07 |
| credit_card_0 | 30.2 | 643 | 58.28 |
| credit_card_1 | 26.8 | 351 | 40.46 |
| last_activity_0 | 20.4 | 579 | 6.92 |
| last_activity_1 | 41.0 | 415 | 114.88 |
''
'Интервал "products >= 4":'
| churn_rate | segment_size | churn_to_total_diff_% | |
|---|---|---|---|
| index | |||
| gender_Ж | 55.1 | 187 | 188.78 |
| gender_М | 66.3 | 291 | 247.48 |
| credit_card_0 | 42.7 | 150 | 123.79 |
| credit_card_1 | 70.7 | 328 | 270.55 |
| last_activity_0 | 91.0 | 100 | 376.94 |
| last_activity_1 | 54.2 | 378 | 184.07 |
''
'Интервал "equity == 4":'
| churn_rate | segment_size | churn_to_total_diff_% | |
|---|---|---|---|
| index | |||
| gender_Ж | 17.2 | 942 | -9.85 |
| gender_М | 33.4 | 876 | 75.05 |
| credit_card_0 | 33.3 | 672 | 74.53 |
| credit_card_1 | 20.2 | 1146 | 5.87 |
| last_activity_0 | 15.2 | 870 | -20.34 |
| last_activity_1 | 34.1 | 948 | 78.72 |
''
'Интервал "equity == 5":'
| churn_rate | segment_size | churn_to_total_diff_% | |
|---|---|---|---|
| index | |||
| gender_Ж | 19.2 | 923 | 0.63 |
| gender_М | 39.7 | 935 | 108.07 |
| credit_card_0 | 36.7 | 761 | 92.35 |
| credit_card_1 | 24.5 | 1097 | 28.41 |
| last_activity_0 | 16.5 | 884 | -13.52 |
| last_activity_1 | 41.3 | 974 | 116.46 |
''
'Интервал "equity >= 6":'
| churn_rate | segment_size | churn_to_total_diff_% | |
|---|---|---|---|
| index | |||
| gender_Ж | 27.5 | 120 | 44.13 |
| gender_М | 48.6 | 146 | 154.72 |
| credit_card_0 | 49.5 | 111 | 159.43 |
| credit_card_1 | 31.6 | 155 | 65.62 |
| last_activity_0 | 20.5 | 112 | 7.44 |
| last_activity_1 | 52.6 | 154 | 175.68 |
''
Исходя из полученных данных о соотношении показателей уточним условия для потенциальных сегментов и объединим интервалы со сходными характеристиками. Это будут сегменты со следующими показателями:
- баланс между 1100000 и 2000000, активные;
- балл кредитного скоринга 850-890, активные;
- возраст 52-59, активные;
- клиенты с 3 продуктами и более, активные;
- клиенты с 4 продуктами и более;
- клиенты с 4 баллами собственности, мужчины, активные;
- клиенты с 5 баллами собственности, мужчины, активные;
- клиенты с 6 баллами собственности и более.
#список условий, по которым выделяются сегменты
conditions = [
'(1100000 <= balance <= 2000000) & (last_activity == 1)',
'(850 <= score <= 890) & (last_activity == 1)',
'(52 <= age <= 59) & (last_activity == 1)',
'(products == 3) & (last_activity == 1)',
'(products >= 4)',
'(equity == 4) & (last_activity == 1) & (gender == "М")',
'(equity == 5) & (last_activity == 1) & (gender == "М")',
'(equity >= 6)',
]
#построение таблицы с характеристиками сегментов
total = pd.DataFrame()
for condition in conditions:
row = df.query(condition).agg({'churn':['mean','count']}).T
row.columns = ['churn_rate','segment_size']
row['churn_clients'] = row['churn_rate'] * row['segment_size']
row['non_churn_clients'] = row['segment_size'] - row['churn_clients']
row['churn_rate'] = round(row['churn_rate'] * 100, 2)
row['churn_to_total_diff_%'] = round(
(row['churn_rate'] / total_ch_rate - 1) * 100,
2
)
row['condition'] = [condition]
total = pd.concat([total,row])
total = (
total
.sort_values(by='churn_rate', ascending=False)
.reset_index(drop=True)
)
display(total.head(20))
total.to_excel(pics_path + 'Сегменты до отбора.xlsx', index=False)
| churn_rate | segment_size | churn_clients | non_churn_clients | churn_to_total_diff_% | condition | |
|---|---|---|---|---|---|---|
| 0 | 61.92 | 478.0 | 296.0 | 182.0 | 224.53 | (products >= 4) |
| 1 | 52.07 | 482.0 | 251.0 | 231.0 | 172.90 | (equity == 5) & (last_activity == 1) & (gender == "М") |
| 2 | 50.98 | 612.0 | 312.0 | 300.0 | 167.19 | (1100000 <= balance <= 2000000) & (last_activity == 1) |
| 3 | 45.96 | 1003.0 | 461.0 | 542.0 | 140.88 | (850 <= score <= 890) & (last_activity == 1) |
| 4 | 42.60 | 446.0 | 190.0 | 256.0 | 123.27 | (equity == 4) & (last_activity == 1) & (gender == "М") |
| 5 | 40.96 | 415.0 | 170.0 | 245.0 | 114.68 | (products == 3) & (last_activity == 1) |
| 6 | 39.10 | 266.0 | 104.0 | 162.0 | 104.93 | (equity >= 6) |
| 7 | 37.84 | 592.0 | 224.0 | 368.0 | 98.32 | (52 <= age <= 59) & (last_activity == 1) |
По таблице видно, что интерес представляют первые четыре сегмента. Остальные четыре имеют сравнительно меньший средний процент оттока и при этом меньший размер.
#удаление менее приоритетных сегментов
total_segments = total[0:4]
#итоговый список сегментов
total_segments
total_segments.to_excel(pics_path + 'Сегменты после отбора.xlsx', index=False)
Описание полученных сегментов.
Клиенты с количеством продуктов 4 и более.
- размер сегмента: 478;
- средний процент оттока: 61,9%;
- уходящих клиентов: 296;
- остающихся клиентов: 182.
Мужчины с баллом собственности 5, проявлявшие активность в последнее время.
- размер сегмента: 482;
- средний процент оттока: 52,1%;
- уходящих клиентов: 251;
- остающихся клиентов: 231.
Клиенты с балансом 1,1 - 2 млн, проявлявшие активность в последнее время.
- размер сегмента: 612;
- средний процент оттока: 51%;
- уходящих клиентов: 312;
- остающихся клиентов: 300.
Клиенты с баллом кредитного скоринга 850-890, проявлявшие активность в последнее время.
- размер сегмента: 1003;
- средний процент оттока: 46%;
- уходящих клиентов: 461;
- остающихся клиентов: 542.
Общий вывод¶
Цель проекта: изучить пользователей банка и сформулировать рекомендации отделу маркетинга по возврату ушедших клиентов и удержанию тех, кто находится в “зоне риска” и склонны к уходу.
Общий результат исследования¶
Результаты проверки статистических гипотез.¶
Есть основания считать, что средний доход уходящих клиентов выше, чем остающихся.
Есть основания считать, что средний балл кредитного скоринга уходящих клиентов выше, чем остающихся.
Есть основания считать, что средний баланс уходящих клиентов выше, чем остающихся.
Выявлены следующие сегменты клиентов, склонных к уходу.
Клиенты с количеством продуктов 4 и более.
- размер сегмента: 478;
- средний процент оттока: 61,9%;
- уходящих клиентов: 296;
- остающихся клиентов: 182.
Мужчины с баллом собственности 5, проявлявшие активность в последнее время.
- размер сегмента: 482;
- средний процент оттока: 52,1%;
- уходящих клиентов: 251;
- остающихся клиентов: 231.
Клиенты с балансом 1,1 - 2 млн, проявлявшие активность в последнее время.
- размер сегмента: 612;
- средний процент оттока: 51%;
- уходящих клиентов: 312;
- остающихся клиентов: 300.
Клиенты с баллом кредитного скоринга 850-890, проявлявшие активность в последнее время.
- размер сегмента: 1003;
- средний процент оттока: 46%;
- уходящих клиентов: 461;
- остающихся клиентов: 542.
Рекомендации для отдела маркетинга.¶
Клиенты с количеством продуктов 4 и более.
Запустить программу лояльности: при наличии у клиентов 4 продуктов банка и более снизить для них стоимость обслуживания продуктов и предложить продукты на более выгодных условиях (например, вклады с повышенной ставкой и кредиты с пониженной ставкой).
Мужчины с баллом собственности 5, проявлявшие активность в последнее время.
Предложить таким клиентам программы страхования объектов собственности (недвижимость, автомобили, вклады и т.д.) на более выгодных условиях.
Предложить таким клиентам кредиты под залог имущества по сниженной ставке.
Клиенты с балансом 1,1 - 2 млн, проявлявшие активность в последнее время.
Запустить программу лояльности: при балансе средств выше определённого порога присваивать клиентам особый статус, позволяющий приобретать особые, разработанные специально для них продукты банка: вклады с повышенной ставкой и кредиты с пониженной ставкой, льготные условия страхования вкладов.
Клиенты с баллом кредитного скоринга 850-890, проявлявшие активность в последнее время.
Устроить акцию: клиентам с кредитным скорингом выше 850 снизить ставку по кредитам и/или предложить программы льготного кредитования и реструктуризации задолженности.
О качестве данных.¶
В датасете присутствуют пропуски.
Столбец age. Пропусков в столбце незначительное количество: 26 (0.26%).
Столбец balance. Пропусков в столбце много: 2293 (22.95%).
Наличие пропусков в столбце balance зависит от имеющихся данных, но в рамках групп, от которых зависит их количество, они распределены случайно, что позволяет отнести их к категории MAR.
80,56% пропусков относятся к группе клиентов с баллом собственности 0 и количеством продуктов 1. Возможно, речь идёт о группе малоимущих клиентов либо клиентов, которые мало пользуются продуктами банка - причину пропусков желательно уточнить и принять меры против их появления в дальнейшем.